fix: normalize MSYS/Unix paths on Windows to prevent phantom shadow branches#845
Open
gtrrz-victor wants to merge 3 commits intomainfrom
Open
fix: normalize MSYS/Unix paths on Windows to prevent phantom shadow branches#845gtrrz-victor wants to merge 3 commits intomainfrom
gtrrz-victor wants to merge 3 commits intomainfrom
Conversation
Contributor
There was a problem hiding this comment.
Pull request overview
This PR addresses Windows-specific path normalization issues that caused transcript-derived paths to break git diff and incorrectly keep files marked as “uncommitted,” creating phantom shadow branches.
Changes:
- Normalize MSYS/Git-Bash-style paths (e.g.,
/c/...,/tmp/...) during absolute→relative conversion and drop unresolvable Unix-style paths on Windows. - Rework
filterToUncommittedFilesto usegit diff --name-only(CLI) instead of go-git content comparisons to improve Windows behavior (locks/autocrlf) and diagnostics.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
cmd/entire/cli/state.go |
Replaces go-git HEAD/working-tree content checks with a git diff-based filter for “already committed” files. |
cmd/entire/cli/paths/paths.go |
Adds MSYS path normalization and filters out Unix-style absolute paths that can’t be resolved/converted on Windows. |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
Bugbot Autofix prepared fixes for both issues found in the latest run.
- ✅ Fixed: git diff drops untracked files breaking documented contract
- I updated
filterToUncommittedFilesto uniongit diff --name-only HEADwithgit ls-files --others --so files not in HEAD are preserved as uncommitted.
- I updated
- ✅ Fixed:
NormalizeMSYSPathlacks platform guard despite docstring claim- I added an early
runtime.GOOS != "windows"return inNormalizeMSYSPathso MSYS rewrites only run on Windows as documented.
- I added an early
Or push these changes by commenting:
@cursor push 3471f3ed42
Preview (3471f3ed42)
diff --git a/cmd/entire/cli/paths/paths.go b/cmd/entire/cli/paths/paths.go
--- a/cmd/entire/cli/paths/paths.go
+++ b/cmd/entire/cli/paths/paths.go
@@ -7,6 +7,7 @@
"os/exec"
"path/filepath"
"regexp"
+ "runtime"
"strings"
"sync"
@@ -155,6 +156,15 @@
// ToRelativePath converts an absolute path to relative.
// Returns empty string if the path is outside the working directory.
func ToRelativePath(absPath, cwd string) string {
+ absPath = NormalizeMSYSPath(absPath)
+
+ // After MSYS normalization, a path starting with "/" that the OS still
+ // doesn't recognize as absolute is an unconvertible Unix path (e.g.,
+ // /home/user/... from a container/sandbox on Windows). Filter it out.
+ if strings.HasPrefix(absPath, "/") && !filepath.IsAbs(absPath) {
+ return ""
+ }
+
if !filepath.IsAbs(absPath) {
return absPath
}
@@ -166,6 +176,34 @@
return relPath
}
+// msysDrivePrefix matches MSYS/Git-Bash-style absolute paths like /c/ or /D/.
+// Git for Windows executes hooks through MSYS2 bash, which converts Windows paths
+// (C:\Users\...) to Unix-style (/c/Users/...) in tool output and transcripts.
+var msysDrivePrefix = regexp.MustCompile(`^/([a-zA-Z])/`)
+
+// NormalizeMSYSPath converts MSYS/Git-Bash paths to Windows paths.
+// Handles two MSYS conventions:
+// - Drive paths: /c/Users/... → C:/Users/...
+// - Virtual dirs: /tmp/... → <TEMP>/... (MSYS2 maps /tmp to the Windows temp dir)
+//
+// Returns the input unchanged on non-Windows or if the path doesn't match.
+func NormalizeMSYSPath(p string) string {
+ if runtime.GOOS != "windows" {
+ return p
+ }
+
+ if m := msysDrivePrefix.FindStringSubmatch(p); m != nil {
+ return strings.ToUpper(m[1]) + ":/" + p[3:]
+ }
+ // MSYS2 maps /tmp to the Windows temp directory.
+ if strings.HasPrefix(p, "/tmp/") {
+ if tmp := os.TempDir(); tmp != "" {
+ return filepath.Join(tmp, p[5:])
+ }
+ }
+ return p
+}
+
// nonAlphanumericRegex matches any non-alphanumeric character
var nonAlphanumericRegex = regexp.MustCompile(`[^a-zA-Z0-9]`)
diff --git a/cmd/entire/cli/paths/paths_test.go b/cmd/entire/cli/paths/paths_test.go
--- a/cmd/entire/cli/paths/paths_test.go
+++ b/cmd/entire/cli/paths/paths_test.go
@@ -3,6 +3,7 @@
import (
"os"
"path/filepath"
+ "runtime"
"testing"
)
@@ -121,3 +122,27 @@
t.Errorf("GetClaudeProjectDir() = %q, want %q", result, expected)
}
}
+
+func TestNormalizeMSYSPath_NonWindows_NoRewrite(t *testing.T) {
+ t.Parallel()
+
+ if runtime.GOOS == "windows" {
+ t.Skip("non-Windows behavior test")
+ }
+
+ tests := []string{
+ "/c/Users/test/repo/file.txt",
+ "/D/work/project/main.go",
+ "/tmp/repo/file.txt",
+ "/tmp",
+ }
+
+ for _, input := range tests {
+ t.Run(input, func(t *testing.T) {
+ got := NormalizeMSYSPath(input)
+ if got != input {
+ t.Errorf("NormalizeMSYSPath(%q) = %q, want unchanged %q", input, got, input)
+ }
+ })
+ }
+}
diff --git a/cmd/entire/cli/state.go b/cmd/entire/cli/state.go
--- a/cmd/entire/cli/state.go
+++ b/cmd/entire/cli/state.go
@@ -7,6 +7,7 @@
"fmt"
"log/slog"
"os"
+ "os/exec"
"path/filepath"
"strings"
"time"
@@ -288,64 +289,89 @@
// filterToUncommittedFiles removes files from the list that are already committed to HEAD
// with matching content. This prevents re-adding files that an agent committed mid-turn
// (already condensed by PostCommit) back to FilesTouched via SaveStep. Files not in
-// HEAD or with different content in the working tree are kept. Fails open: if any git
-// operation errors, returns the original list unchanged.
+// HEAD or with different content in the working tree are kept. Fails open: if git
+// errors (e.g. bare repo, no HEAD), returns the original list unchanged.
+//
+// Uses the git CLI instead of go-git to avoid index lock contention on Windows and
+// to ensure consistent autocrlf/eol handling (git CLI respects system-level config,
+// while go-git's content comparison would need manual CRLF normalization).
func filterToUncommittedFiles(ctx context.Context, files []string, repoRoot string) []string {
if len(files) == 0 {
return files
}
- repo, err := openRepository(ctx)
+ // git diff --name-only HEAD -- <files> prints files that differ from HEAD.
+ // Empty output means tracked files match HEAD, but it does NOT include untracked files.
+ // We handle untracked files separately via git ls-files --others.
+ args := append([]string{"diff", "--name-only", "HEAD", "--"}, files...)
+ cmd := exec.CommandContext(ctx, "git", args...)
+ cmd.Dir = repoRoot
+ out, err := cmd.Output()
if err != nil {
- return files // fail open
+ // Capture stderr for diagnostics (exec.ExitError carries it).
+ var stderr string
+ var exitErr *exec.ExitError
+ if errors.As(err, &exitErr) {
+ stderr = string(exitErr.Stderr)
+ }
+ // Fail open: no HEAD (empty repo, initial commit), not a git repo, etc.
+ logging.Warn(ctx, "filterToUncommittedFiles: git diff failed, keeping all files as uncommitted",
+ slog.String("error", err.Error()),
+ slog.String("stderr", stderr),
+ slog.String("dir", repoRoot),
+ slog.Any("files", files))
+ return files
}
- head, err := repo.Head()
- if err != nil {
- return files // fail open (empty repo, detached HEAD, etc.)
+ // git ls-files --others -- <files> prints untracked files (including ignored ones).
+ // This preserves the contract that files not in HEAD are kept as uncommitted.
+ untrackedArgs := append([]string{"ls-files", "--others", "--"}, files...)
+ untrackedCmd := exec.CommandContext(ctx, "git", untrackedArgs...)
+ untrackedCmd.Dir = repoRoot
+ untrackedOut, untrackedErr := untrackedCmd.Output()
+ if untrackedErr != nil {
+ var stderr string
+ var exitErr *exec.ExitError
+ if errors.As(untrackedErr, &exitErr) {
+ stderr = string(exitErr.Stderr)
+ }
+ logging.Warn(ctx, "filterToUncommittedFiles: git ls-files failed, keeping all files as uncommitted",
+ slog.String("error", untrackedErr.Error()),
+ slog.String("stderr", stderr),
+ slog.String("dir", repoRoot),
+ slog.Any("files", files))
+ return files
}
- commit, err := repo.CommitObject(head.Hash())
- if err != nil {
- return files // fail open
+ trimmed := strings.TrimRight(string(out), "\n")
+ trimmedUntracked := strings.TrimRight(string(untrackedOut), "\n")
+ if trimmed == "" && trimmedUntracked == "" {
+ return nil // all files are committed with matching content
}
- headTree, err := commit.Tree()
- if err != nil {
- return files // fail open
+ // Build sets of files that are uncommitted:
+ // - files differing from HEAD
+ // - files not tracked in HEAD (currently untracked)
+ diffSet := make(map[string]bool)
+ for _, line := range strings.Split(trimmed, "\n") {
+ if line != "" {
+ diffSet[filepath.ToSlash(line)] = true
+ }
}
+ untrackedSet := make(map[string]bool)
+ for _, line := range strings.Split(trimmedUntracked, "\n") {
+ if line != "" {
+ untrackedSet[filepath.ToSlash(line)] = true
+ }
+ }
var result []string
- for _, relPath := range files {
- headFile, err := headTree.File(relPath)
- if err != nil {
- // File not in HEAD — it's uncommitted
- result = append(result, relPath)
- continue
+ for _, f := range files {
+ path := filepath.ToSlash(f)
+ if diffSet[path] || untrackedSet[path] {
+ result = append(result, f)
}
-
- // File is in HEAD — compare content with working tree
- absPath := filepath.Join(repoRoot, relPath)
- workingContent, err := os.ReadFile(absPath) //nolint:gosec // path from controlled source
- if err != nil {
- // Can't read working tree file (deleted?) — keep it
- result = append(result, relPath)
- continue
- }
-
- headContent, err := headFile.Contents()
- if err != nil {
- result = append(result, relPath)
- continue
- }
-
- if string(workingContent) != headContent {
- // Working tree differs from HEAD — uncommitted changes
- result = append(result, relPath)
- }
- // else: content matches HEAD — already committed, skip
}
-
return result
}
diff --git a/cmd/entire/cli/state_test.go b/cmd/entire/cli/state_test.go
--- a/cmd/entire/cli/state_test.go
+++ b/cmd/entire/cli/state_test.go
@@ -9,6 +9,7 @@
"github.com/entireio/cli/cmd/entire/cli/agent/claudecode"
"github.com/entireio/cli/cmd/entire/cli/paths"
+ "github.com/entireio/cli/cmd/entire/cli/testutil"
"github.com/go-git/go-git/v6"
"github.com/go-git/go-git/v6/plumbing/object"
"github.com/stretchr/testify/require"
@@ -796,3 +797,55 @@
})
}
}
+
+func TestFilterToUncommittedFiles_KeepsUntrackedFiles(t *testing.T) {
+ t.Parallel()
+
+ tmpDir := t.TempDir()
+ testutil.InitRepo(t, tmpDir)
+ testutil.WriteFile(t, tmpDir, "tracked.txt", "tracked content")
+ testutil.GitAdd(t, tmpDir, "tracked.txt")
+ testutil.GitCommit(t, tmpDir, "initial commit")
+
+ testutil.WriteFile(t, tmpDir, "untracked.txt", "new file content")
+
+ files := []string{"tracked.txt", "untracked.txt"}
+ got := filterToUncommittedFiles(context.Background(), files, tmpDir)
+
+ want := []string{"untracked.txt"}
+ if len(got) != len(want) {
+ t.Fatalf("filterToUncommittedFiles() = %v, want %v", got, want)
+ }
+ for i := range want {
+ if got[i] != want[i] {
+ t.Errorf("filterToUncommittedFiles()[%d] = %q, want %q", i, got[i], want[i])
+ }
+ }
+}
+
+func TestFilterToUncommittedFiles_KeepsTrackedDiffsAndUntracked(t *testing.T) {
+ t.Parallel()
+
+ tmpDir := t.TempDir()
+ testutil.InitRepo(t, tmpDir)
+ testutil.WriteFile(t, tmpDir, "tracked.txt", "tracked content")
+ testutil.GitAdd(t, tmpDir, "tracked.txt")
+ testutil.GitCommit(t, tmpDir, "initial commit")
+
+ // tracked.txt now differs from HEAD, and untracked.txt is not in HEAD.
+ testutil.WriteFile(t, tmpDir, "tracked.txt", "updated tracked content")
+ testutil.WriteFile(t, tmpDir, "untracked.txt", "new file content")
+
+ files := []string{"tracked.txt", "untracked.txt"}
+ got := filterToUncommittedFiles(context.Background(), files, tmpDir)
+
+ want := []string{"tracked.txt", "untracked.txt"}
+ if len(got) != len(want) {
+ t.Fatalf("filterToUncommittedFiles() = %v, want %v", got, want)
+ }
+ for i := range want {
+ if got[i] != want[i] {
+ t.Errorf("filterToUncommittedFiles()[%d] = %q, want %q", i, got[i], want[i])
+ }
+ }
+}This Bugbot Autofix run was free. To enable autofix for future PRs, go to the Cursor dashboard.
Comment @cursor review or bugbot run to trigger another review on this PR
e45520b to
d98aaea
Compare
…ranches On Windows, transcript-extracted file paths arrive in Unix formats that filepath.IsAbs doesn't recognize (/c/Users/..., /tmp/..., /home/user/...). These leaked through FilterAndNormalizePaths into filterToUncommittedFiles, causing phantom shadow branches that failed test assertions. Add NormalizeMSYSPath to convert known MSYS paths (/c/ → C:/, /tmp/ → temp dir), and drop any remaining Unix-style paths the OS can't resolve. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
d98aaea to
1af6121
Compare
…ndling Cover MSYS drive conversion (/c/ → C:/), /tmp/ mapping, passthrough of already-relative and non-matching paths, and edge cases. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Entire-Checkpoint: 94ff42cf7576
…d tags NormalizeMSYSPath had no runtime.GOOS guard, corrupting valid Unix paths on macOS/Linux (e.g., /tmp/repo/file.txt → /var/folders/...). Move ToRelativePath and NormalizeMSYSPath into relative_windows.go (with MSYS normalization + Unix path filter) and relative_unix.go (plain filepath.Rel, no normalization). Tests split accordingly. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Entire-Checkpoint: cbe58c84ed5b
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.


Summary
filepath.IsAbsdoesn't recognize (/c/Users/...,/tmp/...,/home/user/...). These leaked throughFilterAndNormalizePathsintofilterToUncommittedFiles, causinggit diffto fail with "fatal: Invalid path" or "outside repository" errors. The fail-open behavior then kept the files as "uncommitted", creating a phantom shadow branch.NormalizeMSYSPathto convert MSYS drive paths (/c/→C:/) and virtual dirs (/tmp/→ Windows temp dir), plus a catch-all that drops any remaining Unix-style paths the OS can't resolve (e.g.,/home/user/...from agent sandboxes).filterToUncommittedFilesfrom go-git content comparison togit diffCLI, which handles autocrlf natively and captures stderr for better diagnostics.Root cause
Transcript-extracted paths on Windows arrive in three Unix formats:
/c/Users/.../tmp/...%TEMP%/home/user/...filepath.IsAbs("/c/Users/...")returnsfalseon Windows, soToRelativePathpassed these through unchanged. They reachedgit diff --name-only HEAD -- /home/user/docs/red.mdwhich failed with exit 128. The fail-open behavior kept them as "uncommitted" files → stop hook created a shadow branch →WaitForNoShadowBranchesassertion failed.Test plan
mise run fmt && mise run lint— cleanmise run test— all unit tests passTestSingleSessionSubagentCommitInTurn— 10/10 passes on GitHub Actions🤖 Generated with Claude Code
Note
Medium Risk
Moderate risk: changes core path normalization and the logic that determines which modified files are considered uncommitted, and now shells out to
git diff, which could behave differently across environments if git isn’t available or returns unexpected output.Overview
Fixes Windows path handling by normalizing MSYS/Git-Bash style paths (e.g.,
/c/...,/tmp/...) before converting to repo-relative paths, and dropping remaining Unix-style paths that the OS can’t treat as absolute.Reworks
filterToUncommittedFilesto usegit diff --name-only HEAD -- <files>instead of go-git content comparisons, improving Windows reliability (avoiding index lock contention and respectingautocrlf) and adding stderr-backed warning logs when git diff fails (still fail-open).Written by Cursor Bugbot for commit e45520b. Configure here.